Rate Limiter โ€” System Design Cheatsheet

Token bucket ยท Redis-backed ยท 1M req/s ยท availability > consistency

1. Requirements in one line

Functional

Non-functional

2. Request flow

Client API Gateway (rules cached in memory) 1. Extract client ID from JWT 2. Build key bucket:{id} Atomic LUA on Redis read โ†’ lazy refill โ†’ decrement clock via Redis TIME Allowed? Redis down โ†’ fail-open local bucket = limit/N backend server Redis sharded by user ID consistent hashing TTL auto-cleanup shards 1โ€“15 HTTP EVAL (pooled + pipelined) forward 429 + reset_at

3. Token bucket (the core algorithm)

Store per (user, endpoint)

{ tokens, last_filled }

Lazy refill in LUA on each request:

1. read tokens, last_filled
2. tokens += (now - last_filled) * rate
   tokens = min(tokens, CAPACITY)
3. if tokens >= 1: tokens -= 1 โ†’ allow
   else: โ†’ deny (429)
4. write back, set TTL

One atomic LUA script = no read-modify-write race.

Why token bucket

Alternatives

AlgoTrade
Fixed windowCheap, but 2ร— spike at window edge
Sliding logExact, but stores every timestamp (heavy)
Sliding window counterGood middle ground, approximate

Why Redis โ€” defend the choice

4. Interfaces

isRequestAllowed โ€” the hot-path check

isRequestAllowed(clientId, endpoint)
  โ†’ { allowed, limit,
      remaining, reset_at }

PUT /rules/:id โ€” modify a rule

PUT /rules/{ruleId}
{ scope: "user"|"ip"|"key",
  limit: 50, window: "1m",
  tier: "free" }

5. Follow-ups โ€” the answers to have ready

Burst: doesn't token bucket still allow ~2ร—?

Yes. Full bucket drained at t=0, refills over the minute, drained again near t=60 โ†’ ~200 in a 60s sliding window. The fix is lowering max CAPACITY (not the initial value โ€” it refills back up). That's a policy dial: lower it and legit bursty clients get 429s. State it as a tradeoff, not a bug.

Hot key: one user hammers one shard

Default: gateway-local "blocked" cache. Once Redis says over-limit, cache blocked for a few seconds and stop calling Redis for that user โ€” protects the shard. Escalation (legit whale only): split bucket into K sub-keys user:{id}:0..K-1, each capped at limit/K, pick one at random per request. Cost: remaining/reset_at must sum all K. Don't reach for splitting unless forced.

Redis dies

Fail-open (availability > consistency). Local in-memory bucket per gateway, sized limit/N for N gateways so total stays near the real limit. Choose fail-open vs fail-closed per route โ€” open for low-risk reads, closed for expensive/write routes.

1M req/s with a Redis hop on every request

Connection pooling (skip handshake) + pipelining, Redis in same rack/region (~0.5ms RTT). Shard to spread load. Pooling does not remove the round-trip โ€” say that explicitly.

Rules change mid-flight (100 โ†’ 50)

Grandfather existing buckets, eventual consistency is fine. Rules pushed via ZooKeeper/config, cached in gateway memory. Brief disagreement across gateways during propagation is acceptable.

Multiple rules match (user AND IP AND endpoint)

Evaluate all, enforce the most restrictive. Cost: multiple Redis ops per request (pipeline them).

reset_at with lazy refill

You store last_filled, not a window start. Compute reset_at = now + (tokens_needed / rate) โ€” time until enough tokens refill.

Clock skew across gateways

Use the Redis server clock via TIME inside the LUA script โ†’ one authoritative clock per shard, gateway skew irrelevant.

6. Numbers to drop

Memory

~100 bytes/entry ร— 1M keys = 100 MB. Even at 10 endpoints/user โ†’ ~1 GB. Fits comfortably in RAM. Small footprint = a point in your favor.

Throughput

One Redis node โ‰ˆ 100K ops/s. 1M req/s โ†’ ~10โ€“15 shards. Consistent hashing so adding shards moves few keys.

7. 30-second recap script

Client hits the gateway. Gateway extracts identity from the JWT, builds bucket:{id}, and runs one atomic LUA script on the Redis shard for that key: read tokens, lazily refill by elapsed time, decrement if any remain. Allowed โ†’ forward; empty โ†’ 429 with reset_at. Redis sharded by user ID via consistent hashing, TTL cleans up idle keys. Rules cached in gateway memory, pushed on change. If Redis is down we fail open to a local limit/N bucket, because availability beats consistency for a limiter. Hot keys handled by a local blocked-cache, with key-splitting held in reserve for legit whales.